fix(cli): ensure type:module before Vite loads vite.config.ts (fixes #184)#197
Conversation
|
/bigbonk review |
commit: |
|
@copilot The failing job encountered repeated 500 Internal Server Error responses from GitHub during git fetch operations, resulting in exit code 128. This is not an issue with your code or workflow configuration. |
|
@southpolesteve @elithrar kindly review the changes. |
|
kindly re-review the changes. There was a GitHub issue which failed the deploy examples/ Comment Preview URL |
|
/bigbonk review this again please! |
There was a problem hiding this comment.
Review Summary
The fix addresses a real problem (issue #184): projects without "type": "module" in package.json break Vite's config loading on Node 22. The approach of reusing ensureESModule + renameCJSConfigs from utils/project.ts is sound, and the test coverage is thorough.
However, I have concerns about silently mutating the user's package.json and renaming their config files as a side effect of vinext dev / vinext build. This is a significant behavioral change that needs more careful handling. I also found some issues in the implementation.
Key Concerns
-
Silent file mutations during dev/build are dangerous. Renaming
postcss.config.js→.cjsand rewritingpackage.jsonduring avinext devinvocation can surprise users. If the process crashes or is killed mid-write, it can leave the project in an inconsistent state (e.g.,postcss.config.jsrenamed buttype:modulenot yet added). Consider wrapping both operations atomically, or at minimum doing thepackage.jsonwrite before the renames so a partial failure is recoverable. -
The warning message is misleading. It says "Run
vinext initto make this change permanent" — but the change was already written to disk by the time the user sees this message. There's nothing impermanent about it. Either remove the misleading language or make the behavior truly non-persistent (e.g., settype:moduleas an env/flag that Vite reads, rather than mutatingpackage.json). -
Tests are thorough but don't test the actual
ensureViteConfigCompatibilityfunction. The new tests exerciseensureESModuleandrenameCJSConfigsin isolation (which already had tests in the same file). The new wrapper functionensureViteConfigCompatibility— which has its own logic (checkinghasViteConfig(), reading and parsingpackage.json, the early-return conditions) — has no direct tests. This is the code most likely to have bugs. -
Two
describeblocks with near-identical tests. The PR adds both "ESM config compatibility — issue #184" and "ensureESModule + renameCJSConfigs — issue #184 compatibility" blocks that largely duplicate each other and also duplicate the existingensureESModuleandrenameCJSConfigsdescribe blocks.
| ` warning.` | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Bug: ensureViteConfigCompatibility duplicates package.json parsing.
This function reads and parses package.json itself (lines 201-212), then calls ensureESModule(root) which reads and parses the same package.json again. More importantly, if ensureESModule returns false for any reason (file disappeared, parse error in its own try/catch), the if (added) check silently swallows the failure — the user gets no feedback that something went wrong after CJS configs were already renamed.
This redundant parsing also means there's a TOCTOU race: the function checks pkg.type === "module" at line 212, then ensureESModule checks it again. If something else modifies the file between the two reads, behavior is unpredictable.
Consider removing the duplicate parsing and just calling ensureESModule directly, letting it handle the idempotency check internally (which it already does):
| } | |
| function ensureViteConfigCompatibility(root: string): void { | |
| // Only act when there is a vite.config — auto-config mode sets | |
| // configFile: false and doesn't go through Vite's file-loading path. | |
| if (!hasViteConfig()) return; | |
| const pkgPath = path.join(root, "package.json"); | |
| if (!fs.existsSync(pkgPath)) return; | |
| // Rename any `.js` CJS config files first so they don't break after we | |
| // add "type": "module". | |
| const renamed = renameCJSConfigs(root); | |
| for (const [oldName, newName] of renamed) { | |
| console.warn( | |
| ` [vinext] Renamed ${oldName} → ${newName} (required for "type": "module")` | |
| ); | |
| } | |
| // Add "type": "module" so Vite loads vite.config.ts as ESM. | |
| const added = ensureESModule(root); | |
| if (added) { | |
| console.warn( | |
| ` [vinext] Added "type": "module" to package.json so Vite can load` + | |
| ` vite.config.ts as ESM.\n` + | |
| ` Run \`vinext init\` to review all project configuration.` | |
| ); | |
| } | |
| } |
packages/vinext/src/cli.ts
Outdated
| console.warn( | ||
| ` [vinext] Added "type": "module" to package.json so Vite can load` + | ||
| ` vite.config.ts as ESM.\n` + | ||
| ` Run \`vinext init\` to make this change permanent and avoid this` + |
There was a problem hiding this comment.
Misleading warning message. The change is already written to package.json by this point — it's not temporary or reversible. Telling users to "run vinext init to make this change permanent" implies the change isn't permanent yet, which is confusing.
Consider rewording to something like:
[vinext] Added "type": "module" to package.json (required for Vite ESM config loading).
Run `vinext init` to review all project configuration.
packages/vinext/src/cli.ts
Outdated
|
|
||
| // Rename any `.js` CJS config files first so they don't break after we | ||
| // add "type": "module". | ||
| const renamed = renameCJSConfigs(root); |
There was a problem hiding this comment.
CJS config renames happen even when type:module is already set. The early return if (pkg.type === "module") return; at line 212 prevents this in the normal case, but if package.json parsing fails (line 207 catch), the function returns early before renames. That's fine. However, if someone has "type": "commonjs" explicitly set, this code will rename their CJS configs and add "type": "module" — overriding an explicit user choice. You might want to check for pkg.type !== undefined (any explicit value) rather than just pkg.type === "module".
| // and generate `require()` calls for ESM-only packages like | ||
| // @cloudflare/vite-plugin, which fails on Node 22 with: | ||
| // "Dynamic require of .../index.mjs is not supported" | ||
| // |
There was a problem hiding this comment.
Duplicate test coverage. This describe("ESM config compatibility — issue #184") block adds tests for ensureESModule and renameCJSConfigs that are largely identical to the existing describe("ensureESModule") (line 860) and describe("renameCJSConfigs") (line 906) blocks, and also to the second new block describe("ensureESModule + renameCJSConfigs — issue #184 compatibility") (line 1868).
For example:
- "adds type:module when vite.config.ts is present" — same as existing test at line 861
- "is idempotent" — same as existing test at line 872
- "renames CJS postcss.config.js" — same as existing test at line 907
Consider removing one of the two new describe blocks entirely (they test the same functions) and focusing the remaining tests on scenarios not already covered — like the monorepo case and the "preserves all fields" case, which are genuinely new.
packages/vinext/src/cli.ts
Outdated
| function ensureViteConfigCompatibility(root: string): void { | ||
| // Only act when there is a vite.config — auto-config mode sets | ||
| // configFile: false and doesn't go through Vite's file-loading path. | ||
| if (!hasViteConfig()) return; |
There was a problem hiding this comment.
hasViteConfig() uses process.cwd() but root is passed as a parameter. The hasViteConfig() function (defined at line 135) hardcodes process.cwd(), while this function accepts root as a parameter. If anyone ever calls ensureViteConfigCompatibility with a root different from process.cwd(), the vite config check will look in the wrong directory.
Currently this is safe because both call sites pass process.cwd(), but it's fragile. Consider using the hasViteConfig(root) from utils/project.ts instead, which accepts a root parameter:
| if (!hasViteConfig()) return; | |
| if (!hasViteConfig()) return; |
Should be:
// Import hasViteConfig from utils/project.ts which accepts root param
// or inline the check here
const hasConfig =
fs.existsSync(path.join(root, "vite.config.ts")) ||
fs.existsSync(path.join(root, "vite.config.js")) ||
fs.existsSync(path.join(root, "vite.config.mjs"));
if (!hasConfig) return;
Note: There's already a hasViteConfig(root: string) exported from utils/project.ts (line 107) that does exactly this — you're already importing from that module.
|
Review posted successfully. Here's a summary of the feedback I left on PR #197: Review: REQUEST_CHANGESThe fix addresses a real problem (issue #184) and the approach of reusing shared utilities is sound. However, I identified several issues: Implementation bugs/concerns:
Test concerns:
|
edadc53 to
f1c52d4
Compare
|
@southpolesteve Kindly review the latest changes. The workflow is all green. |
|
@hyoban Kindly review the latest changes. The workflow is all green. |
|
@james-elicx kindly review the PR. |
|
/bigbonk review |
There was a problem hiding this comment.
Review
The fix addresses a real problem and the approach of reusing ensureESModule + renameCJSConfigs from utils/project.ts is sound. I can see that the previous reviewer's feedback has been partially addressed — the hasViteConfig now correctly uses hasViteConfigInRoot(root) from utils/project.ts, the explicit "type": "commonjs" case is handled, and the warning message is improved.
However, there are still a couple of issues worth addressing:
1. Duplicate package.json parsing (minor, but real)
ensureViteConfigCompatibility reads and parses package.json (lines 201-209) to check early-return conditions, then calls ensureESModule(root) which reads and parses package.json again. This is harmless for correctness (the second read is idempotent), but it's wasted I/O. More importantly, it means the guard logic (the pkg.type !== undefined check at line 215) lives here while ensureESModule has its own guard (pkg.type === "module"). If someone later changes the guard in one place but not the other, behavior diverges.
Consider either:
- Removing the duplicate parsing and relying solely on
ensureESModule's internal idempotency, OR - Adding a
typefield check parameter toensureESModuleso the guard lives in one place
2. ensureViteConfigCompatibility is not directly tested
The new tests exercise ensureESModule and renameCJSConfigs in isolation (which already have thorough tests in the same file). The actual function added to cli.ts — ensureViteConfigCompatibility — is the one with the new logic (vite config check, package.json existence check, the type !== undefined guard for explicit CJS), and it has no direct test coverage. The test comment says "Simulate what ensureViteConfigCompatibility does" but then just calls the two underlying utilities directly.
I understand this function is private to cli.ts and harder to test in isolation, but it would be worth either: exporting it for testing, or adding an integration-level test that exercises the CLI path.
3. Test duplication with existing coverage
The new test block at line 1809 has significant overlap with the existing describe("ensureESModule") (line 860) and describe("renameCJSConfigs") (line 906) blocks. For example:
- "preserves all existing package.json fields" — near-identical to line 884
- "handles multiple CJS config files simultaneously" — near-identical to line 931
The two genuinely new tests are the monorepo workspace scenario and the full fix scenario (both utilities composed together). I'd suggest trimming the duplicates and keeping only the tests that add new coverage.
Overall the implementation is solid and the fix is correct. The issues above are cleanup-level — none are blockers.
packages/vinext/src/cli.ts
Outdated
| } | ||
|
|
||
| // Add "type": "module" so Vite loads vite.config.ts as ESM. | ||
| const added = ensureESModule(root); |
There was a problem hiding this comment.
Minor: ensureESModule will re-read and re-parse package.json here, despite having already parsed it at line 206. This is harmless but redundant. If ensureESModule fails for any reason (returns false), this code silently proceeds after having already renamed CJS configs — the user could end up with renamed configs but no type:module, which is the inconsistent state the previous reviewer flagged.
One option: since you already have the parsed pkg object, write type:module directly here instead of delegating to ensureESModule:
| const added = ensureESModule(root); | |
| // Add "type": "module" so Vite loads vite.config.ts as ESM. | |
| try { | |
| pkg.type = "module"; | |
| fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8"); | |
| console.warn( | |
| ` [vinext] Added "type": "module" to package.json (required for Vite ESM config loading).\n` + | |
| ` Run \`vinext init\` to review all project configuration.` | |
| ); | |
| } catch { | |
| // If we can't write, Vite will fail with a clearer error downstream. | |
| } |
This eliminates the double read, and if the write fails, at least the CJS renames are still valid (they just mean .cjs extension with no semantic change since type:module wasn't added).
tests/deploy.test.ts
Outdated
| expect(rootPkg.type).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("preserves all existing package.json fields when adding type:module", () => { |
There was a problem hiding this comment.
This test ("preserves all existing package.json fields") is near-identical to the existing test at line 884. Consider removing it and keeping only the tests that provide new coverage — the monorepo scenario (line 1841) and the full integration scenario (line 1810) are the genuinely new ones.
tests/deploy.test.ts
Outdated
| expect(pkg.devDependencies.vinext).toBe("^0.0.13"); | ||
| }); | ||
|
|
||
| it("handles multiple CJS config files simultaneously", () => { |
There was a problem hiding this comment.
This test ("handles multiple CJS config files simultaneously") is near-identical to the existing test at line 931. Consider removing this duplicate.
tests/deploy.test.ts
Outdated
| "module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };", | ||
| ); | ||
|
|
||
| // Simulate what ensureViteConfigCompatibility does |
There was a problem hiding this comment.
The comment says "Simulate what ensureViteConfigCompatibility does" — but it's calling the two utilities directly rather than testing the actual wrapper function. This means the wrapper's own logic (the vite config existence check, the pkg.type !== undefined guard for explicit CJS) has no test coverage.
If the function can't easily be exported from cli.ts, consider at minimum adding a test that verifies the "type": "commonjs" guard doesn't get overridden — that's the most important new behavior in the wrapper that isn't covered by existing tests of the underlying utilities.
|
Review posted successfully. Here's a summary of the feedback I left on PR #197: Overall: The fix is correct and addresses a real problem (issue #184). The approach of reusing shared utilities from Three remaining items (cleanup-level, not blockers):
|
f1c52d4 to
3cc70c5
Compare
|
/bigbonk review |
There was a problem hiding this comment.
Review
The fix addresses a real issue (#184) and the approach — reusing the existing ensureESModule + renameCJSConfigs utilities before Vite loads config — is correct. The previous reviewer feedback on hasViteConfig(root) and the explicit "type": "commonjs" guard has been addressed.
However, there are two issues that should be resolved before merging: duplicate package.json parsing with a silent failure mode, and tests that don't cover the actual new code. There's also significant test duplication that should be trimmed.
Summary of requested changes
-
Eliminate the double
package.jsonread —ensureViteConfigCompatibilityparsespackage.jsonat line 207, thenensureESModuleparses it again internally. More importantly, ifensureESModulefails silently (returnsfalsedue to a write error), the user ends up with CJS configs already renamed to.cjsbut notype:moduleadded — a broken state. Since you already have the parsedpkgobject, writetype:moduledirectly instead of delegating. -
Test
ensureViteConfigCompatibilityitself, not just its sub-functions — The new tests callensureESModuleandrenameCJSConfigsdirectly, which are already thoroughly tested in existingdescribeblocks. The actual new logic — the vite config existence check, thepkg.type !== undefinedguard for explicit CJS, the ordering of operations — has zero test coverage. -
Remove duplicate tests — Two of the four new tests are near-identical to existing ones ("preserves all existing package.json fields" ≈ line 983, "handles multiple CJS config files simultaneously" ≈ line 1030). Keep the two genuinely new scenarios (full integration + monorepo workspace) and drop the duplicates.
packages/vinext/src/cli.ts
Outdated
| // Add "type": "module" so Vite loads vite.config.ts as ESM. | ||
| const added = ensureESModule(root); | ||
| if (added) { | ||
| console.warn( | ||
| ` [vinext] Added "type": "module" to package.json (required for Vite ESM config loading).\n` + | ||
| ` Run \`vinext init\` to review all project configuration.` | ||
| ); | ||
| } |
There was a problem hiding this comment.
This re-reads and re-parses package.json (you already parsed it at line 207). If ensureESModule fails internally (e.g. writeFileSync throws, caught by its own try/catch), it returns false silently — but CJS configs were already renamed on line 220. The user is left with .cjs config files and no type:module, which is the worst of both worlds.
Since you already have the parsed pkg object and the pkgPath, write directly:
| // Add "type": "module" so Vite loads vite.config.ts as ESM. | |
| const added = ensureESModule(root); | |
| if (added) { | |
| console.warn( | |
| ` [vinext] Added "type": "module" to package.json (required for Vite ESM config loading).\n` + | |
| ` Run \`vinext init\` to review all project configuration.` | |
| ); | |
| } | |
| // Add "type": "module" so Vite loads vite.config.ts as ESM. | |
| try { | |
| pkg.type = "module"; | |
| fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8"); | |
| console.warn( | |
| ` [vinext] Added "type": "module" to package.json (required for Vite ESM config loading).\n` + | |
| ` Run \`vinext init\` to review all project configuration.` | |
| ); | |
| } catch { | |
| // If we can't write, Vite will fail with a clearer error downstream. | |
| } | |
| } |
This eliminates the double read and ensures that if the write fails, we at least don't print a misleading success message. The CJS renames are still valid on their own (they just mean the file has a .cjs extension, which is correct for CJS regardless of the type field).
tests/deploy.test.ts
Outdated
| // Simulate what ensureViteConfigCompatibility does | ||
| const renamed = renameCJSConfigs(tmpDir); | ||
| const added = ensureESModule(tmpDir); |
There was a problem hiding this comment.
The comment says "Simulate what ensureViteConfigCompatibility does" — but the test calls the two underlying utilities directly, bypassing all of the wrapper's logic. This means the following new behavior in the wrapper has no test coverage:
- The
hasViteConfigInRoot(root)guard — skipping when no vite config exists - The
pkg.type !== undefinedguard — respecting explicit"type": "commonjs" - The ordering guarantee (CJS renames happen before
type:moduleis added)
I understand ensureViteConfigCompatibility is a private function in cli.ts. Two options:
- Export it (even as a named export for testing only) and test it directly
- At minimum, add a test that verifies a project with
"type": "commonjs"is not mutated — that's the most important new guard and it has zero coverage
tests/deploy.test.ts
Outdated
| it("preserves all existing package.json fields when adding type:module", () => { | ||
| writeFile( | ||
| tmpDir, | ||
| "package.json", | ||
| JSON.stringify({ | ||
| name: "web", | ||
| version: "0.1.0", | ||
| private: true, | ||
| scripts: { dev: "vinext dev", build: "vinext build" }, | ||
| dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" }, | ||
| devDependencies: { vinext: "^0.0.13", "@cloudflare/vite-plugin": "^1.0.0" }, | ||
| }), | ||
| ); | ||
|
|
||
| ensureESModule(tmpDir); | ||
|
|
||
| const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8")); | ||
| expect(pkg.type).toBe("module"); | ||
| expect(pkg.name).toBe("web"); | ||
| expect(pkg.version).toBe("0.1.0"); | ||
| expect(pkg.private).toBe(true); | ||
| expect(pkg.scripts.build).toBe("vinext build"); | ||
| expect(pkg.dependencies.react).toBe("^19.0.0"); | ||
| expect(pkg.devDependencies.vinext).toBe("^0.0.13"); | ||
| }); |
There was a problem hiding this comment.
This test ("preserves all existing package.json fields") is near-identical to the existing test at line 983 ("preserves existing package.json fields"). The only difference is more fields in the fixture JSON. This doesn't add meaningful coverage — JSON.parse + JSON.stringify preserves all fields by definition.
Consider removing this test.
tests/deploy.test.ts
Outdated
| it("handles multiple CJS config files simultaneously", () => { | ||
| writeFile( | ||
| tmpDir, | ||
| "package.json", | ||
| JSON.stringify({ name: "my-app", version: "1.0.0" }), | ||
| ); | ||
| writeFile(tmpDir, "postcss.config.js", "module.exports = {};"); | ||
| writeFile(tmpDir, "tailwind.config.js", "module.exports = {};"); | ||
| writeFile(tmpDir, ".eslintrc.js", "module.exports = {};"); | ||
|
|
||
| const renamed = renameCJSConfigs(tmpDir); | ||
| ensureESModule(tmpDir); | ||
|
|
||
| expect(renamed).toHaveLength(3); | ||
| expect(fs.existsSync(path.join(tmpDir, "postcss.config.cjs"))).toBe(true); | ||
| expect(fs.existsSync(path.join(tmpDir, "tailwind.config.cjs"))).toBe(true); | ||
| expect(fs.existsSync(path.join(tmpDir, ".eslintrc.cjs"))).toBe(true); | ||
| const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8")); | ||
| expect(pkg.type).toBe("module"); | ||
| }); |
There was a problem hiding this comment.
This test ("handles multiple CJS config files simultaneously") is near-identical to the existing test at line 1030 ("renames multiple CJS configs at once"). Same setup, same assertions.
Consider removing this test and keeping only the two genuinely new scenarios: the full integration test (line 2008) and the monorepo workspace test (line 2039).
|
Review posted successfully on PR #197. Here's a summary of the feedback: REQUEST_CHANGES — 3 issues to resolve:
|
…loudflare#184) Projects that lack "type":"module" in package.json caused Vite to bundle vite.config.ts through esbuild's CJS path, producing a .vite-temp/*.mjs file that contains a CJS require() for @cloudflare/vite-plugin. On Node 22 in CI (GitHub Actions ubuntu-latest), require()-ing an .mjs file is not supported and the build fails with: Error: Dynamic require of "…/@cloudflare/vite-plugin/dist/index.mjs" is not supported Fix: call ensureESModule(root) + renameCJSConfigs(root) in both the dev() and buildApp() CLI commands, before Vite is loaded. This mirrors what vinext init already does, but applies lazily for projects that were set up manually or before the init step was introduced. - renameCJSConfigs renames postcss.config.js / tailwind.config.js / .eslintrc.js etc. to .cjs first, to avoid breakage in those files after ESM mode is enabled - ensureESModule adds "type":"module" to package.json - Both are no-ops when the project is already correctly configured - A warning is emitted when a change is made, pointing users to vinext init - 9 new unit tests cover the full scenario including multi-file rename Fixes cloudflare#184
3cc70c5 to
5ba8333
Compare
Problem
Projects without
"type": "module"inpackage.jsoncause Vite to bundlevite.config.tsthrough esbuild's CJS path, producing a.vite-temp/*.timestamp.mjsfile that containsrequire()calls for the imported plugins. On Node 22 in CI (GitHub Actions, ubuntu-latest),require()-ing a.mjsfile is not supported and the build fails immediately:The affected user had set up their project manually (without
vinext init) or before theinitstep added"type": "module".vinext initalready adds this field, butvinext dev/vinext buildwere not enforcing it.Fix
Call
ensureESModule(root)+renameCJSConfigs(root)in both thedev()andbuildApp()CLI commands, before Vite is loaded. This reuses the same logic already used byvinext init.renameCJSConfigsrenamespostcss.config.js/tailwind.config.js/.eslintrc.jsetc. →.cjsfirst, preventing breakage in those files once"type": "module"is addedensureESModuleadds"type": "module"topackage.jsonvinext initto make it permanentTests
9 new unit tests added to
tests/deploy.test.tsunder the describe block "ensureESModule + renameCJSConfigs — issue #184 compatibility":type:modulewhen missingfalsewhen nopackage.jsonexistspostcss.config.js→postcss.config.cjsbefore addingtype:modulepostcss.config.js(nomodule.exportsorrequire())tailwind.config.jswhen CJS.eslintrc.jswhen CJSrequire()(not onlymodule.exports)All 169 tests pass (
pnpm exec vitest run tests/deploy.test.ts).Fixes #184